1use super::core::{
9 GameWithDetails, RecommendationConfig, RecommendationReason, SeriesLimit, UserPreferenceVector,
10 UserSettings,
11};
12use super::scoring::{normalize_score, score_game_cb_detailed, DetailedScoreComponents};
13use serde::Serialize;
14use std::collections::{HashMap, HashSet};
15
16#[derive(Debug, Serialize, Clone)]
20pub struct DetailedScoreBreakdown {
21 pub game_id: String,
22 pub game_title: String,
23 pub steam_app_id: Option<u32>,
24
25 pub affinity_score: f32,
27 pub context_score: f32,
28 pub diversity_score: f32,
29
30 pub genre_score: f32,
32 pub tag_score: f32,
33 pub series_score: f32,
34
35 pub total_cb: f32,
37 pub total_cf: f32,
38
39 pub normalized_cb: f32,
41 pub normalized_cf: f32,
42
43 pub weighted_cb: f32,
45 pub weighted_cf: f32,
46
47 pub age_penalty: f32,
49
50 pub final_score: f32,
52 pub final_rank: usize,
53
54 pub reason_label: String,
56 pub reason_type: String,
57
58 pub top_genres: Vec<(String, f32)>,
60 pub top_affinity_tags: Vec<(String, f32)>,
61 pub top_context_tags: Vec<(String, f32)>,
62}
63
64#[derive(Debug, Serialize, Clone)]
66pub struct RecommendationAnalysisReport {
67 pub timestamp: String,
68 pub total_games: usize,
69 pub config: RecommendationConfig,
70 pub user_settings: UserSettingsReport,
71
72 pub profile_stats: ProfileStats,
74
75 pub stats: AnalysisStats,
77
78 pub games: Vec<DetailedScoreBreakdown>,
80
81 pub tag_influence: Vec<(String, TagInfluence)>,
83 pub genre_influence: Vec<(String, GenreInfluence)>,
84 pub reason_distribution: HashMap<String, usize>,
85}
86
87#[derive(Debug, Serialize, Clone)]
88pub struct ProfileStats {
89 pub total_genres: usize,
90 pub total_tags: usize,
91 pub total_series: usize,
92 pub top_genres: Vec<(String, f32)>,
93 pub top_tags: Vec<(String, String, f32)>, }
95
96#[derive(Debug, Serialize, Clone)]
97pub struct UserSettingsReport {
98 pub filter_adult_content: bool,
99 pub series_limit: String,
100}
101
102#[derive(Debug, Serialize, Clone)]
103pub struct AnalysisStats {
104 pub avg_final_score: f32,
105 pub median_final_score: f32,
106 pub max_final_score: f32,
107 pub min_final_score: f32,
108
109 pub avg_cb_score: f32,
110 pub avg_cf_score: f32,
111
112 pub avg_affinity_score: f32,
113 pub avg_context_score: f32,
114 pub avg_diversity_score: f32,
115
116 pub avg_genre_score: f32,
117 pub avg_tag_score: f32,
118 pub avg_series_score: f32,
119
120 pub avg_age_penalty: f32,
121
122 pub affinity_proportion: f32,
124 pub context_proportion: f32,
125 pub diversity_proportion: f32,
126 pub genre_proportion: f32,
127}
128
129#[derive(Debug, Serialize, Clone)]
130pub struct TagInfluence {
131 pub tag_name: String,
132 pub category: String,
133 pub role: String,
134 pub games_count: usize,
135 pub avg_contribution: f32,
136 pub max_contribution: f32,
137 pub times_as_reason: usize,
138}
139
140#[derive(Debug, Serialize, Clone)]
141pub struct GenreInfluence {
142 pub games_count: usize,
143 pub avg_contribution: f32,
144 pub max_contribution: f32,
145 pub times_as_reason: usize,
146}
147
148pub fn generate_analysis_report(
155 profile: &UserPreferenceVector,
156 candidates: &[GameWithDetails],
157 cf_scores: &HashMap<u32, f32>,
158 ignored_ids: &HashSet<String>,
159 config: RecommendationConfig,
160 user_settings: UserSettings,
161) -> RecommendationAnalysisReport {
162 use super::filtering::apply_hard_filters;
163 use chrono::Local;
164
165 let filtered = apply_hard_filters(candidates, &user_settings);
167
168 let raw_results: Vec<_> = filtered
170 .iter()
171 .filter(|g| !ignored_ids.contains(&g.game.id))
172 .map(|g| {
173 let (cb_score, cb_reason, components) = score_game_cb_detailed(profile, g, &config);
174
175 let cf_score = g
176 .steam_app_id
177 .and_then(|id| cf_scores.get(&id))
178 .cloned()
179 .unwrap_or(0.0);
180
181 (g.clone(), cb_score, cf_score, cb_reason, components)
182 })
183 .collect();
184
185 let max_cb = raw_results
187 .iter()
188 .map(|(_, c, _, _, _)| *c)
189 .fold(0.0, f32::max);
190 let max_cf = raw_results
191 .iter()
192 .map(|(_, _, c, _, _)| *c)
193 .fold(0.0, f32::max);
194
195 let mut games_breakdowns = create_score_breakdowns(raw_results, max_cb, max_cf, &config);
197
198 games_breakdowns.sort_by(|a, b| b.final_score.partial_cmp(&a.final_score).unwrap());
200
201 for (idx, game) in games_breakdowns.iter_mut().enumerate() {
203 game.final_rank = idx + 1;
204 }
205
206 let stats = calculate_stats(&games_breakdowns);
208
209 let tag_influence = analyze_tag_influence(&games_breakdowns, candidates);
211
212 let genre_influence = analyze_genre_influence(&games_breakdowns);
214
215 let reason_distribution = calculate_reason_distribution(&games_breakdowns);
217
218 let profile_stats = calculate_profile_stats(profile);
220
221 RecommendationAnalysisReport {
222 timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
223 total_games: games_breakdowns.len(),
224 config: config.clone(),
225 user_settings: UserSettingsReport {
226 filter_adult_content: user_settings.filter_adult_content,
227 series_limit: match user_settings.series_limit {
228 SeriesLimit::None => "none".to_string(),
229 SeriesLimit::Moderate => "moderate".to_string(),
230 SeriesLimit::Aggressive => "aggressive".to_string(),
231 },
232 },
233 profile_stats,
234 stats,
235 games: games_breakdowns,
236 tag_influence,
237 genre_influence,
238 reason_distribution,
239 }
240}
241
242fn create_score_breakdowns(
245 raw_results: Vec<(
246 GameWithDetails,
247 f32,
248 f32,
249 Option<RecommendationReason>,
250 DetailedScoreComponents,
251 )>,
252 max_cb: f32,
253 max_cf: f32,
254 config: &RecommendationConfig,
255) -> Vec<DetailedScoreBreakdown> {
256 raw_results
257 .into_iter()
258 .enumerate()
259 .filter_map(|(idx, (g, cb, cf, cb_reason, components))| {
260 if cb == 0.0 && cf == 0.0 {
261 return None;
262 }
263
264 let cb_n = normalize_score(cb, max_cb);
265 let cf_n = normalize_score(cf, max_cf);
266
267 let weighted_cb = cb_n * config.content_weight;
268 let weighted_cf = cf_n * config.collaborative_weight;
269
270 let final_score = weighted_cb + weighted_cf;
271
272 let (reason_label, reason_type) = determine_reason(weighted_cb, weighted_cf, cb_reason);
274
275 Some(DetailedScoreBreakdown {
276 game_id: g.game.id.clone(),
277 game_title: g.game.name.clone(),
278 steam_app_id: g.steam_app_id,
279
280 affinity_score: components.affinity_score,
281 context_score: components.context_score,
282 diversity_score: components.diversity_score,
283
284 genre_score: components.genre_score,
285 tag_score: components.tag_score,
286 series_score: components.series_score,
287
288 total_cb: cb,
289 total_cf: cf,
290
291 normalized_cb: cb_n,
292 normalized_cf: cf_n,
293
294 weighted_cb,
295 weighted_cf,
296
297 age_penalty: components.age_penalty,
298
299 final_score,
300 final_rank: idx + 1,
301
302 reason_label,
303 reason_type,
304
305 top_genres: components.top_genres,
306 top_affinity_tags: components.top_affinity_tags,
307 top_context_tags: components.top_context_tags,
308 })
309 })
310 .collect()
311}
312
313fn determine_reason(
314 weighted_cb: f32,
315 weighted_cf: f32,
316 cb_reason: Option<RecommendationReason>,
317) -> (String, String) {
318 match (weighted_cb > 0.0, weighted_cf > 0.0) {
319 (true, true) => (
320 "Afinidade + Popular na comunidade".to_string(),
321 "hybrid".to_string(),
322 ),
323 (false, true) => ("Popular na comunidade".to_string(), "community".to_string()),
324 _ => {
325 if let Some(reason) = cb_reason {
326 (reason.label, reason.type_id)
327 } else {
328 ("Baseado no seu perfil".to_string(), "general".to_string())
329 }
330 }
331 }
332}
333
334fn calculate_stats(games: &[DetailedScoreBreakdown]) -> AnalysisStats {
335 if games.is_empty() {
336 return AnalysisStats {
337 avg_final_score: 0.0,
338 median_final_score: 0.0,
339 max_final_score: 0.0,
340 min_final_score: 0.0,
341 avg_cb_score: 0.0,
342 avg_cf_score: 0.0,
343 avg_affinity_score: 0.0,
344 avg_context_score: 0.0,
345 avg_diversity_score: 0.0,
346 avg_genre_score: 0.0,
347 avg_tag_score: 0.0,
348 avg_series_score: 0.0,
349 avg_age_penalty: 1.0,
350 affinity_proportion: 0.0,
351 context_proportion: 0.0,
352 diversity_proportion: 0.0,
353 genre_proportion: 0.0,
354 };
355 }
356
357 let n = games.len() as f32;
358 let sum_final: f32 = games.iter().map(|g| g.final_score).sum();
359 let sum_cb: f32 = games.iter().map(|g| g.total_cb).sum();
360 let sum_cf: f32 = games.iter().map(|g| g.total_cf).sum();
361 let sum_affinity: f32 = games.iter().map(|g| g.affinity_score).sum();
362 let sum_context: f32 = games.iter().map(|g| g.context_score).sum();
363 let sum_diversity: f32 = games.iter().map(|g| g.diversity_score).sum();
364 let sum_genre: f32 = games.iter().map(|g| g.genre_score).sum();
365 let sum_tag: f32 = games.iter().map(|g| g.tag_score).sum();
366 let sum_series: f32 = games.iter().map(|g| g.series_score).sum();
367 let sum_age_penalty: f32 = games.iter().map(|g| g.age_penalty).sum();
368
369 let total_cb = sum_affinity + sum_context + sum_diversity;
370 let (affinity_prop, context_prop, diversity_prop, genre_prop) = if total_cb > 0.0 {
371 (
372 (sum_affinity / total_cb) * 100.0,
373 (sum_context / total_cb) * 100.0,
374 (sum_diversity / total_cb) * 100.0,
375 (sum_genre / total_cb) * 100.0,
376 )
377 } else {
378 (0.0, 0.0, 0.0, 0.0)
379 };
380
381 let median_final_score = calculate_median(games);
382
383 AnalysisStats {
384 avg_final_score: sum_final / n,
385 median_final_score,
386 max_final_score: games.iter().map(|g| g.final_score).fold(0.0, f32::max),
387 min_final_score: games.iter().map(|g| g.final_score).fold(f32::MAX, f32::min),
388 avg_cb_score: sum_cb / n,
389 avg_cf_score: sum_cf / n,
390 avg_affinity_score: sum_affinity / n,
391 avg_context_score: sum_context / n,
392 avg_diversity_score: sum_diversity / n,
393 avg_genre_score: sum_genre / n,
394 avg_tag_score: sum_tag / n,
395 avg_series_score: sum_series / n,
396 avg_age_penalty: sum_age_penalty / n,
397 affinity_proportion: affinity_prop,
398 context_proportion: context_prop,
399 diversity_proportion: diversity_prop,
400 genre_proportion: genre_prop,
401 }
402}
403
404fn calculate_median(games: &[DetailedScoreBreakdown]) -> f32 {
405 let mut sorted_scores: Vec<f32> = games.iter().map(|g| g.final_score).collect();
406 sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
407
408 if sorted_scores.len() % 2 == 0 {
409 let mid = sorted_scores.len() / 2;
410 (sorted_scores[mid - 1] + sorted_scores[mid]) / 2.0
411 } else {
412 sorted_scores[sorted_scores.len() / 2]
413 }
414}
415
416fn analyze_tag_influence(
417 games: &[DetailedScoreBreakdown],
418 candidates: &[GameWithDetails],
419) -> Vec<(String, TagInfluence)> {
420 let mut tag_data: HashMap<String, (String, String, Vec<f32>, usize)> = HashMap::new();
421
422 for game in games {
424 if let Some(original) = candidates.iter().find(|c| c.game.id == game.game_id) {
426 for (tag_name, contribution) in &game.top_affinity_tags {
428 if let Some(tag) = original.tags.iter().find(|t| &t.name == tag_name) {
430 let entry = tag_data.entry(tag_name.clone()).or_insert((
431 format!("{:?}", tag.category),
432 "affinity".to_string(),
433 Vec::new(),
434 0,
435 ));
436 entry.2.push(*contribution);
437 }
438 }
439
440 if game.reason_type == "tag" && game.reason_label.starts_with("Tag: ") {
442 let tag_name = game.reason_label.strip_prefix("Tag: ").unwrap();
443 if let Some(entry) = tag_data.get_mut(tag_name) {
444 entry.3 += 1;
445 }
446 }
447 }
448 }
449
450 let mut result: Vec<(String, TagInfluence)> = tag_data
452 .into_iter()
453 .map(|(name, (category, role, contributions, times_as_reason))| {
454 let avg = if !contributions.is_empty() {
455 contributions.iter().sum::<f32>() / contributions.len() as f32
456 } else {
457 0.0
458 };
459 let max = contributions.iter().cloned().fold(0.0, f32::max);
460
461 (
462 name.clone(),
463 TagInfluence {
464 tag_name: name,
465 category,
466 role,
467 games_count: contributions.len(),
468 avg_contribution: avg,
469 max_contribution: max,
470 times_as_reason,
471 },
472 )
473 })
474 .collect();
475
476 result.sort_by(|a, b| {
478 b.1.avg_contribution
479 .partial_cmp(&a.1.avg_contribution)
480 .unwrap()
481 });
482
483 result
484}
485
486fn analyze_genre_influence(games: &[DetailedScoreBreakdown]) -> Vec<(String, GenreInfluence)> {
487 let mut genre_data: HashMap<String, (Vec<f32>, usize)> = HashMap::new();
488
489 for game in games {
490 for (genre_name, contribution) in &game.top_genres {
491 let entry = genre_data
492 .entry(genre_name.clone())
493 .or_insert((Vec::new(), 0));
494 entry.0.push(*contribution);
495 }
496
497 if game.reason_type == "genre" && game.reason_label.starts_with("Gênero: ") {
499 if let Some(genre_name) = game.reason_label.strip_prefix("Gênero: ") {
500 if let Some(entry) = genre_data.get_mut(genre_name) {
501 entry.1 += 1;
502 }
503 }
504 }
505 }
506
507 let mut result: Vec<(String, GenreInfluence)> = genre_data
508 .into_iter()
509 .map(|(name, (contributions, times_as_reason))| {
510 let avg = if !contributions.is_empty() {
511 contributions.iter().sum::<f32>() / contributions.len() as f32
512 } else {
513 0.0
514 };
515 let max = contributions.iter().cloned().fold(0.0, f32::max);
516
517 (
518 name,
519 GenreInfluence {
520 games_count: contributions.len(),
521 avg_contribution: avg,
522 max_contribution: max,
523 times_as_reason,
524 },
525 )
526 })
527 .collect();
528
529 result.sort_by(|a, b| {
530 b.1.avg_contribution
531 .partial_cmp(&a.1.avg_contribution)
532 .unwrap()
533 });
534
535 result
536}
537
538fn calculate_reason_distribution(games: &[DetailedScoreBreakdown]) -> HashMap<String, usize> {
539 let mut distribution = HashMap::new();
540 for game in games {
541 *distribution.entry(game.reason_label.clone()).or_insert(0) += 1;
542 }
543 distribution
544}
545
546fn calculate_profile_stats(profile: &UserPreferenceVector) -> ProfileStats {
547 let mut profile_genres: Vec<(String, f32)> = profile
548 .genres
549 .iter()
550 .map(|(k, v)| (k.clone(), *v))
551 .collect();
552 profile_genres.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
553
554 let mut profile_tags_temp: Vec<(String, f32)> = profile
555 .tags
556 .iter()
557 .map(|(k, v)| (format!("{:?}:{}", k.category, k.slug), *v))
558 .collect();
559 profile_tags_temp.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
560
561 let profile_tags: Vec<(String, String, f32)> = profile_tags_temp
562 .into_iter()
563 .take(20)
564 .map(|(key, val)| {
565 let parts: Vec<&str> = key.split(':').collect();
566 (parts.get(1).unwrap_or(&"").to_string(), key.clone(), val)
567 })
568 .collect();
569
570 ProfileStats {
571 total_genres: profile.genres.len(),
572 total_tags: profile.tags.len(),
573 total_series: profile.series.len(),
574 top_genres: profile_genres.into_iter().take(10).collect(),
575 top_tags: profile_tags,
576 }
577}